<?php
/*
** Copyright (C) 2001-2025 Zabbix SIA
**
** This program is free software: you can redistribute it and/or modify it under the terms of
** the GNU Affero General Public License as published by the Free Software Foundation, version 3.
**
** This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
** without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
** See the GNU Affero General Public License for more details.
**
** You should have received a copy of the GNU Affero General Public License along with this program.
** If not, see <https://www.gnu.org/licenses/>.
**/


abstract class CController {

	protected const POST_CONTENT_TYPE_FORM = 0;
	protected const POST_CONTENT_TYPE_JSON = 1;

	protected const INPUT_VALIDATION_DEFAULT = 0;
	protected const INPUT_VALIDATION_FORM = 1;

	protected const VALIDATION_OK = 0;
	protected const VALIDATION_ERROR = 1;
	protected const VALIDATION_FATAL_ERROR = 2;

	/**
	 * Content type of the POST request.
	 *
	 * @var int
	 */
	private $post_content_type = self::POST_CONTENT_TYPE_FORM;

	/**
	 * Input validation method.
	 *
	 * @var int
	 */
	private $input_validation_method = self::INPUT_VALIDATION_DEFAULT;

	/**
	 * Action name, so that controller knows which action is being executed.
	 *
	 * @var string
	 */
	private $action;

	/**
	 * Response object generated by controller.
	 *
	 * @var CControllerResponse
	 */
	private $response;

	/**
	 * Result of input validation, one of VALIDATION_OK, VALIDATION_ERROR, VALIDATION_FATAL_ERROR.
	 *
	 * @var int
	 */
	private $validation_result;

	/**
	 * Errors of input validation if $input_validation_method is INPUT_VALIDATION_FORM.
	 *
	 * @var array
	 */
	private $validation_errors = [];

	/**
	 * Non-validated input parameters.
	 *
	 * @var array|null
	 */
	private $raw_input;

	/**
	 * Validated input parameters.
	 *
	 * @var array
	 */
	protected $input = [];

	/**
	 * Non-validated files parameters.
	 *
	 * @var array|null
	 */
	private ?array $raw_files;

	/**
	 * Validated files parameters.
	 *
	 * @var array|null
	 */
	private array $files = [];

	/**
	 * Validate CSRF token flag, if true CSRF token must be validated.
	 *
	 * @var bool
	 */
	private bool $validate_csrf_token = true;

	public function __construct() {
		$this->init();
		$this->populateRawInput();
	}

	/**
	 * Initialization function that can be overridden later.
	 */
	protected function init() {
	}

	/**
	 * Get content type of the POST request.
	 *
	 * @return int
	 */
	protected function getPostContentType(): int {
		return $this->post_content_type;
	}

	/**
	 * Set content type of the POST request.
	 *
	 * @param int $post_content_type
	 */
	protected function setPostContentType(int $post_content_type): void {
		$this->post_content_type = $post_content_type;
	}

	/**
	 * Set input validation method.
	 *
	 * @param int $input_validation_method
	 */
	protected function setInputValidationMethod(int $input_validation_method): void {
		$this->input_validation_method = $input_validation_method;
	}

	/**
	 * Return controller action name.
	 *
	 * @return string
	 */
	public function getAction() {
		return $this->action;
	}

	/**
	 * Set controller action name.
	 *
	 * @param string $action
	 */
	public function setAction($action) {
		$this->action = $action;
	}

	/**
	 * Return controller response object.
	 *
	 * @return CControllerResponse
	 */
	public function getResponse() {
		return $this->response;
	}

	/**
	 * Set controller response.
	 *
	 * @param CControllerResponse $response
	 */
	protected function setResponse($response) {
		$this->response = $response;
	}

	/**
	 * Return debug mode.
	 *
	 * @return bool
	 */
	protected function getDebugMode() {
		return CWebUser::getDebugMode();
	}

	/**
	 * Return user type.
	 *
	 * @return int
	 */
	protected function getUserType() {
		return CWebUser::getType();
	}

	/**
	 * Checks access of current user to specific access rule.
	 *
	 * @param string $rule_name  Rule name.
	 *
	 * @return bool  Returns true if user has access to rule, false - otherwise.
	 */
	protected function checkAccess(string $rule_name): bool {
		return CWebUser::checkAccess($rule_name);
	}

	/**
	 * Disables CSRF token validation.
	 */
	protected function disableCsrfValidation(): void {
		$this->validate_csrf_token = false;
	}

	/**
	 * @throws Exception
	 *
	 * @return array
	 */
	private static function getFormInput(): array {
		static $input;

		if ($input === null) {
			$input = $_REQUEST;

			if (hasRequest('formdata')) {
				if (!hasRequest('data') || !is_string(getRequest('data'))
						|| !hasRequest('sign') || !is_string(getRequest('sign'))) {
					throw new Exception(_('Operation cannot be performed due to unauthorized request.'));
				}

				$data = base64_decode(getRequest('data'));
				$sign = base64_decode(getRequest('sign'));
				$request_sign = CEncryptHelper::sign($data);

				if (CEncryptHelper::checkSign($sign, $request_sign)) {
					$data = json_decode($data, true);

					if ($data['messages']) {
						CMessageHelper::setScheduleMessages($data['messages']);
					}

					$input = array_replace($input, $data['form']);
				}
				else {
					info(_('Operation cannot be performed due to unauthorized request.'));
				}

				// Replace window.history to avoid resubmission warning dialog.
				zbx_add_post_js("history.replaceState({}, '');");
			}
		}

		return $input;
	}

	/**
	 * @return array
	 */
	private static function getJsonInput(): array {
		static $input;

		if ($input === null) {
			$input = $_REQUEST;

			$json_input = json_decode(file_get_contents('php://input'), true);

			if (is_array($json_input)) {
				$input += $json_input;
			}
			else {
				info(_('JSON array input is expected.'));
			}
		}

		return $input;
	}

	private static function getFileInput(): array {
		return $_FILES;
	}

	/**
	 * Validate input parameters.
	 *
	 * @param array $validation_rules
	 *
	 * @return bool
	 */
	protected function validateInput(array $validation_rules): bool {
		if ($this->raw_input === null && $this->raw_files === null) {
			$this->validation_result = self::VALIDATION_FATAL_ERROR;

			return false;
		}

		return $this->input_validation_method == self::INPUT_VALIDATION_FORM
			? $this->validateWithFormValidator($validation_rules)
			: $this->validateWithNewValidator($validation_rules);
	}

	/**
	 * Validate input using CFormValidator.
	 *
	 * @param array    $validation_rules  Validation rules.
	 *
	 * @return bool
	 */
	protected function validateWithFormValidator(array $validation_rules): bool {
		$validator = new CFormValidator($validation_rules);
		$data = $this->raw_input;
		$files = $this->raw_files;

		switch ($validator->validate($data, $files)) {
			case CFormValidator::SUCCESS:
				$this->validation_result = self::VALIDATION_OK;
				$this->validation_errors = [];
				$this->input = $data;
				$this->files = $files;
				break;

			case CFormValidator::ERROR:
				$this->validation_errors = $validator->getErrors();
				$this->validation_result = self::VALIDATION_ERROR;
				break;

			case CFormValidator::ERROR_FATAL:
				$this->validation_errors = $validator->getErrors();
				$this->validation_result = self::VALIDATION_FATAL_ERROR;
				break;
		}

		return $this->validation_result == self::VALIDATION_OK;
	}

	public function addFormError(string $path, string $message, $level = CFormValidator::ERROR_LEVEL_UNKNOWN): void {
		if (!array_key_exists($path, $this->validation_errors)) {
			$this->validation_errors[$path] = [];
		}

		$same_error = array_filter($this->validation_errors[$path],
			static fn ($error) => $error['message'] === $message && $error['level'] == $level
		);

		if (!$same_error) {
			$this->validation_errors[$path][] = [
				'message' => $message,
				'level' => $level
			];
			$this->validation_result = self::VALIDATION_ERROR;
		}
	}

	/**
	 * Validate input using CNewValidator.
	 *
	 * @param array    $validation_rules  Validation rules.
	 *
	 * @return bool
	 */
	protected function validateWithNewValidator(array $validation_rules): bool {
		$validator = new CNewValidator($this->raw_input, $validation_rules);

		foreach ($validator->getAllErrors() as $error) {
			info($error);
		}

		if ($validator->isErrorFatal()) {
			$this->validation_result = self::VALIDATION_FATAL_ERROR;
		}
		else {
			$this->input = $validator->getValidInput();
			$this->validation_result = $validator->isError() ? self::VALIDATION_ERROR : self::VALIDATION_OK;
		}

		return $this->validation_result == self::VALIDATION_OK;
	}

	/**
	 * Validate "from" and "to" parameters for allowed period.
	 *
	 * @throws CAccessDeniedException
	 *
	 * @return bool
	 */
	protected function validateTimeSelectorPeriod(): bool {
		if (!$this->hasInput('from') || !$this->hasInput('to')) {
			return true;
		}

		try {
			$min_period = CTimePeriodHelper::getMinPeriod();
			$max_period = CTimePeriodHelper::getMaxPeriod();
		}
		catch (Exception $x) {
			throw new CAccessDeniedException();
		}

		$range_time_parser = new CRangeTimeParser();

		$time_period = [
			'from' => $this->getInput('from'),
			'to' => $this->getInput('to')
		];

		foreach (['from' => 'from_ts', 'to' => 'to_ts'] as $field => $field_ts) {
			$range_time_parser->parse($time_period[$field]);
			$time_period[$field_ts] = $range_time_parser->getDateTime($field === 'from')->getTimestamp();
		}

		$period = $time_period['to_ts'] - $time_period['from_ts'] + 1;

		if ($period < $min_period) {
			info(_n('Minimum time period to display is %1$s minute.',
				'Minimum time period to display is %1$s minutes.', (int) ($min_period / SEC_PER_MIN)
			));

			return false;
		}
		elseif ($period > $max_period + 1) {
			info(_n('Maximum time period to display is %1$s day.',
				'Maximum time period to display is %1$s days.', (int) round($max_period / SEC_PER_DAY)
			));

			return false;
		}

		return true;
	}

	/**
	 * Return validation result.
	 *
	 * @return int
	 */
	protected function getValidationResult() {
		return $this->validation_result;
	}

	/**
	 * Return validation errors.
	 *
	 * @return array
	 */
	protected function getValidationError(): array {
		return $this->validation_errors;
	}

	/**
	 * Check if input parameter exists.
	 *
	 * @param string $var
	 *
	 * @return bool
	 */
	protected function hasInput($var) {
		return array_key_exists($var, $this->input);
	}

	/**
	 * Get single input parameter.
	 *
	 * @param string $var
	 * @param mixed $default
	 *
	 * @return mixed
	 */
	protected function getInput($var, $default = null) {
		if ($default === null) {
			return $this->input[$var];
		}
		else {
			return array_key_exists($var, $this->input) ? $this->input[$var] : $default;
		}
	}

	/**
	 * Get several input parameters.
	 *
	 * @param array $var
	 * @param array $names
	 */
	protected function getInputs(&$var, $names) {
		foreach ($names as $name) {
			if ($this->hasInput($name)) {
				$var[$name] = $this->getInput($name);
			}
		}
	}

	/**
	 * Return all input parameters.
	 *
	 * @return array
	 */
	protected function getInputAll() {
		return $this->input;
	}

	protected function hasFile(string $var): bool {
		return array_key_exists($var, $this->files);
	}

	protected function getFile(string $var): array {
		return $this->files[$var];
	}

	/**
	 * Check user permissions.
	 *
	 * @abstract
	 *
	 * @return bool
	 */
	abstract protected function checkPermissions();

	/**
	 * Validate input parameters.
	 *
	 * @abstract
	 *
	 * @return bool
	 */
	abstract protected function checkInput();

	/**
	 * Checks if CSRF token in the request is valid.
	 *
	 * @return bool
	 */
	private function checkCsrfToken(): bool {
		if (!isRequestMethod('post') || !is_array($this->raw_input)
				|| !array_key_exists(CSRF_TOKEN_NAME, $this->raw_input)) {
			return false;
		}

		$skip = ['popup', 'massupdate'];
		$csrf_token_form = $this->raw_input[CSRF_TOKEN_NAME];

		if (!is_string($csrf_token_form)) {
			return false;
		}

		if (strpos(get_class($this), 'Modules\\') === 0) {
			return CCsrfTokenHelper::check($csrf_token_form, $this->action);
		}

		foreach (explode('.', $this->action) as $segment) {
			if (!in_array($segment, $skip, true)) {
				return CCsrfTokenHelper::check($csrf_token_form, $segment);
			}
		}

		return false;
	}

	/**
	 * Execute action and generate response object.
	 *
	 * @abstract
	 */
	abstract protected function doAction();

	private function populateRawInput(): void {
		switch ($this->getPostContentType()) {
			case self::POST_CONTENT_TYPE_FORM:
				$this->raw_input = self::getFormInput();
				$this->raw_files = self::getFileInput();
				break;

			case self::POST_CONTENT_TYPE_JSON:
				$this->raw_input = self::getJsonInput();
				$this->raw_files = null;
				break;

			default:
				$this->raw_input = null;
				$this->raw_files = null;
		}
	}

	/**
	 * Main controller processing routine. Returns response object: data, redirect or fatal redirect.
	 *
	 * @throws CAccessDeniedException
	 *
	 * @return CControllerResponse|null
	 */
	final public function run(): ?CControllerResponse {
		if ($this->validate_csrf_token && (!CWebUser::isLoggedIn() || !$this->checkCsrfToken())) {
			throw new CAccessDeniedException();
		}

		if ($this->checkInput()) {
			if ($this->checkPermissions() !== true) {
				throw new CAccessDeniedException();
			}

			$this->doAction();
		}

		return $this->getResponse();
	}
}
